#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2009-2011 Greg Neagle.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# 
#      http://www.apache.org/licenses/LICENSE-2.0
# 
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
makepkginfo

Created by Greg Neagle on 2008-11-25.
Creates a managed install pkg info plist given an Installer item:
a .pkg, a .mpkg, or a .dmg containing a .pkg or .mpkg
at the root of the mounted disk image.

You may also pass additional items that are installed by the package. These
are added to the 'installs' key of the catalog item plist and are used when 
processing the catalog to check if the package needs to be installed or 
reinstalled.

The generated plist is printed to STDOUT.

Usage: makepkginfo /path/to/package_or_dmg [-f /path/to/item/it/installs ...]
"""

import sys
import os
import re
import optparse
from optparse import OptionValueError
import subprocess

from munkilib import munkicommon
from munkilib import FoundationPlist
from munkilib import adobeutils


def getCatalogInfoFromDmg(dmgpath, options):
    """
    * Mounts a disk image 
    * Gets catalog info for the first installer item found at the root level.
    * Unmounts the disk image
    
    To-do: handle multiple installer items on a disk image(?)
    """
    cataloginfo = None
    mountpoints = munkicommon.mountdmg(dmgpath)
    if not mountpoints:
        print >> sys.stderr, "Could not mount %s!" % dmgpath
        exit(-1)
        
    if options.pkgname:
        pkgpath = os.path.join(mountpoints[0], options.pkgname)
        if os.path.exists(pkgpath):
            cataloginfo = munkicommon.getPackageMetaData(pkgpath)
            if cataloginfo:
                cataloginfo['package_path'] = options.pkgname
    elif not options.item:
        # search for first package at root
        for fsitem in munkicommon.listdir(mountpoints[0]):
            itempath = os.path.join(mountpoints[0], fsitem)
            if itempath.endswith('.pkg') or itempath.endswith('.mpkg'):
                cataloginfo = munkicommon.getPackageMetaData(itempath)
                # get out of fsitem loop
                break
                
        if cataloginfo:
            # we found a package, but let's see if it's an Adobe CS5 install 
            # (AAMEE) package
            if 'receipts' in cataloginfo:
                try:
                    pkgid = cataloginfo['receipts'][0].get('packageid')
                except IndexError:
                    pkgid = ""
                if pkgid.startswith("com.adobe.Enterprise.install"):
                    # we have an Adobe CS5 install package, process
                    # as Adobe install
                    adobepkgname = cataloginfo['receipts'][0].get('filename')
                    cataloginfo = adobeutils.getAdobeCatalogInfo(
                                                mountpoints[0], adobepkgname)
                
        else:
            # maybe an Adobe installer/updater/patcher?
            cataloginfo = adobeutils.getAdobeCatalogInfo(mountpoints[0], 
                                                        options.pkgname or '')
                    
    if not cataloginfo:
        # maybe this is a drag-n-drop dmg
        # look for given item or an app at the top level of the dmg
        iteminfo = {}
        if options.item:
            item = options.item
            itempath = os.path.join(mountpoints[0], item)
            if os.path.exists(itempath):
                iteminfo = getiteminfo(itempath)
            else:
                print >> sys.stderr, \
                    "%s not found on disk image." % item
        else:
            # no item specified; look for an application at root of
            # mounted dmg
            item = ''
            for itemname in munkicommon.listdir(mountpoints[0]):
                itempath = os.path.join(mountpoints[0], itemname)
                if munkicommon.isApplication(itempath):
                    item = itemname
                    iteminfo = getiteminfo(itempath)
                    if iteminfo:
                        break
                        
        if iteminfo:
            if options.destinationpath:
                iteminfo['path'] = os.path.join(options.destinationpath,
                                                                    item)
            else:          
                iteminfo['path'] = os.path.join("/Applications", item)
            cataloginfo = {}
            cataloginfo['name'] = iteminfo.get('CFBundleName',
                                            os.path.splitext(item)[0])
            cataloginfo['version'] = \
                iteminfo.get('CFBundleShortVersionString', "0")
            cataloginfo['installs'] = [iteminfo]
            if options.appdmg:
                cataloginfo['installer_type'] = "appdmg"
                cataloginfo['uninstallable'] = True
                cataloginfo['uninstall_method'] = "remove_app"
            else:
                cataloginfo['installer_type'] = "copy_from_dmg"
                item_to_copy = {}
                item_to_copy['source_item'] = item
                item_to_copy['destination_path'] = \
                            options.destinationpath or "/Applications"
                if options.user:
                    item_to_copy['user'] = options.user
                if options.group:
                    item_to_copy['group'] = options.group
                if options.mode:
                    item_to_copy['mode'] = options.mode
                cataloginfo['items_to_copy'] = [item_to_copy]
                cataloginfo['uninstallable'] = True
                cataloginfo['uninstall_method'] = "remove_copied_items"
                    
    #eject the dmg
    munkicommon.unmountdmg(mountpoints[0])
    return cataloginfo   


def getBundleInfo(path):
    """
    Returns Info.plist data if available
    for bundle at path
    """
    infopath = os.path.join(path, "Contents", "Info.plist")
    if not os.path.exists(infopath):
        infopath = os.path.join(path, "Resources", "Info.plist")
        
    if os.path.exists(infopath):
        try:
            plist = FoundationPlist.readPlist(infopath)
            return plist
        except FoundationPlist.NSPropertyListSerializationException:
            pass

    return None
    

def readfile(path):
    '''Reads file at path. Returns a string.'''
    try:
        fileobject = open(os.path.expanduser(path), mode='r', buffering=1)
        data = fileobject.read()
        fileobject.close()
        return data
    except (OSError, IOError):
        print >> sys.stderr, "Couldn't read %s" % path
        return ""


def getiteminfo(itempath):
    """
    Gets info for filesystem items passed to makecatalog item, to be used for
    the "installs" key.
    Determines if the item is an application, bundle, Info.plist, or a file or 
    directory and gets additional metadata for later comparison.
    """
    infodict = {}
    if munkicommon.isApplication(itempath):
        infodict['type'] = 'application'
        infodict['path'] = itempath
        plist = getBundleInfo(itempath)
        if 'CFBundleName' in plist:
            infodict['CFBundleName'] = plist['CFBundleName']
        if 'CFBundleIdentifier' in plist:
            infodict['CFBundleIdentifier'] = plist['CFBundleIdentifier']
        infodict['CFBundleShortVersionString'] = \
            munkicommon.getVersionString(plist)
        if 'LSMinimumSystemVersion' in plist:
            infodict['minosversion'] = plist['LSMinimumSystemVersion']
        elif 'SystemVersionCheck:MinimumSystemVersion' in plist:
            infodict['minosversion'] = \
                plist['SystemVersionCheck:MinimumSystemVersion']
            
    elif os.path.exists(os.path.join(itempath, 'Contents', 'Info.plist')) or \
         os.path.exists(os.path.join(itempath, 'Resources', 'Info.plist')):
        infodict['type'] = 'bundle'
        infodict['path'] = itempath
        plist = getBundleInfo(itempath)
        infodict['CFBundleShortVersionString'] = \
            munkicommon.getVersionString(plist)
            
    elif itempath.endswith("Info.plist") or \
         itempath.endswith("version.plist"):
        infodict['type'] = 'plist'
        infodict['path'] = itempath
        try:
            plist = FoundationPlist.readPlist(itempath)
            infodict['CFBundleShortVersionString'] = \
                munkicommon.getVersionString(plist)
        except FoundationPlist.NSPropertyListSerializationException:
            pass
        
    if not 'CFBundleShortVersionString' in infodict and \
       not 'CFBundleVersion' in infodict:
        infodict['type'] = 'file'
        infodict['path'] = itempath
        if os.path.isfile(itempath):
            infodict['md5checksum'] = munkicommon.getmd5hash(itempath)
    return infodict


def check_mode(option, opt, value, parser):
    '''Callback to check --mode options'''
    modes = value.lower().replace(',', ' ').split()
    value = None
    rex = re.compile("[augo]+[=+-][rstwxXugo]+")
    for mode in modes:
        if rex.match(mode):
            value = mode if not value else (value + "," + mode)
        else:
            raise OptionValueError("option %s: invalid mode: %s" % 
                                                            (opt, mode))
    setattr(parser.values, option.dest, value)


def main():
    '''Main routine'''
    usage = """usage: %prog [options] [/path/to/installeritem]
       %prog --help for more information."""
    p = optparse.OptionParser(usage=usage)
    p.add_option('--file', '-f', action="append",
                    help='''Path to a filesystem item installed by this 
                    package, typically an application. This generates an 
                    "installs" item for the pkginfo, an item munki can 
                    use to determine if this software has been installed.
                    Can be specified multiple times.''')
    p.add_option('--pkgname', '-p',
                    help='''Optional flag.
                    
                    -If the installer item is a disk image containing
                    multiple packages, or the package to be installed
                    is not at the root of the mounted disk image, PKGNAME
                    is a relative path from the root of the mounted
                    disk image to the specific package to be installed.
                    
                    -If the installer item is a disk image containing
                    an Adobe CS4 Deployment Toolkit installation, PKGNAME
                    is the name of an Adobe CS4 Deployment Toolkit 
                    installer package folder at the top level of the 
                    mounted dmg.
                    
                    If this flag is missing, the AdobeUber* files should
                    be at the top level of the mounted dmg.''')
    p.add_option('--appdmg', action="store_true",
                    help='''Optional flag.
                    
                    Causes makepkginfo to create a pkginfo item describing 
                    an appdmg install instead of the newer copy_from_dmg 
                    installer type. Meant for use with older munki 
                    clients, as copy_from_dmg replaces appdmg in munki 
                    0.6.0 and later.''')
    p.add_option('--itemname', '-i', '--appname', '-a',
                    metavar='ITEM',
                    dest='item',
                    help='''Optional flag.
                    
                    -If the installer item is a disk image with a 
                    drag-and-drop item, ITEMNAME is the name or 
                    relative path of the item to be installed. 
                    Useful if there is more than one item at the 
                    root of the dmg.''')
    p.add_option('--displayname',
                    help='''Optional flag.
                    
                    String display name of the package.
                    Note: overrides any display_name in the package itself''')
    p.add_option('--description',
                    help='''Optional flag.
                    
                    String description of the package.
                    Note: overrides any description in the package itself''')
    p.add_option('--destinationpath', '-d',
                    help='''Optional flag.
                    
                    If the installer item is a disk image with a 
                    drag-and-drop item, this is the path to which 
                    the item should be copied. Defaults to 
                    "/Applications".''')
    p.add_option('--uninstallerdmg', '-u',
                    help='''Optional flag.
                    
                    If the installer item is a disk image containing an
                    Adobe CS4 Deployment Toolkit installation package or 
                    Adobe CS3 deployment package, UNINSTALLERDMG is a path 
                    to a disk image containing an AdobeUberUninstaller for
                    this item.''')
    p.add_option('--postinstall_script',
                    metavar='SCRIPT_PATH',
                    help='''Optional flag.
                    
                    Path to an optional postinstall script to be run after  
                    installation of the item. The script will be read and 
                    embedded into the pkginfo.''')
    p.add_option('--preinstall_script',
                    metavar='SCRIPT_PATH',
                    help='''Optional flag.
                    
                    Path to an optional preinstall script to be run before 
                    installation of the item. The script will be read and 
                    embedded into the pkginfo.''')
    p.add_option('--postuninstall_script',
                    metavar='SCRIPT_PATH',
                    help='''Optional flag.

                    Path to an optional postuninstall script to be run after  
                    removal of the item. The script will be read and 
                    embedded into the pkginfo.''')
    p.add_option('--preuninstall_script',
                    metavar='SCRIPT_PATH',
                    help='''Optional flag.

                    Path to an optional preuninstall script to be run before 
                    removal of the item. The script will be read and 
                    embedded into the pkginfo.''')
    p.add_option('--uninstall_script',
                    metavar='SCRIPT_PATH',
                    help='''Optional flag.
                    
                    Path to an uninstall script to be run in order to
                    uninstall this item. The script will be read and 
                    embedded into the pkginfo.''')
    p.add_option('--catalog', '-c', action="append",
                    help='''Optional flag.
                    
                    Specifies in which catalog the item should appear. The 
                    default is 'testing'. Can be specified multiple times 
                    to add the item to multiple catalogs.''')
    p.add_option('-o', '--owner',
                    metavar='USER',
                    dest='user',
                    help='''Optional flag.
                    
                    If the installer item is a disk image used with
                    the copy_from_dmg installer type, this sets the 
                    owner of the item specified by the --item flag. 
                    The owner may be either a UID or a symbolic name. 
                    The owner will be set recursively on the item.''')
    p.add_option('-g', '--group',
                    metavar='GROUP',
                    dest='group',
                    help='''Optional flag.
                    
                    If the installer item is a disk image used with
                    the copy_from_dmg installer type, this sets the 
                    group of the item specified by the --item flag. 
                    The group may be either a GID or a symbolic name. 
                    The group will be set recursively on the item.''')
    p.add_option('-m', '--mode',
                    metavar='MODE',
                    dest='mode',
                    action='callback',
                    type='string',
                    callback=check_mode,
                    help='''Optional flag.
                    
                    If the installer item is a disk used with
                    the copy_from_dmg installer type, this sets the 
                    mode of the item specified by the --item flag. 
                    The specified mode must be in symbolic form.
                    See the manpage for chmod(1) for more information. 
                    The mode is applied recursively.''')
    p.add_option('--version', '-V', action='store_true',
                      help='Print the version of the munki tools and exit.')
    
    options, arguments = p.parse_args()
    
    if options.version:
        print munkicommon.get_version()
        exit(0)
    
    if (len(arguments) == 0 
        and not options.file 
        and not options.preinstall_script
        and not options.postinstall_script
        and not options.preuninstall_script
        and not options.postuninstall_script
        and not options.uninstall_script):
        p.print_usage()
        exit(-1)
    
    if len(arguments) > 1:
        print >> sys.stderr, 'Can process only one installer item at a time.'
        print >> sys.stderr, 'Ignoring additional installer items:'
        print >> sys.stderr, '\t', '\n\t'.join(arguments[1:])
    
    catinfo = {}
    installs = []
    if arguments:
        item = arguments[0].rstrip("/")
        if item and os.path.exists(item):
            # get size of installer item
            itemsize = 0 
            itemhash = "N/A"
            if os.path.isfile(item):
                itemsize = int(os.path.getsize(item))
                itemhash = munkicommon.getsha256hash(item)
            
            if item.endswith('.dmg'):
                catinfo = getCatalogInfoFromDmg(item, options)
                if (catinfo and 
                    catinfo.get('installer_type') == "AdobeCS5Installer"):
                    print >> sys.stderr, (
                        "This disk image appears to contain an Adobe CS5 "
                        "product install.\n"
                        "Please use Adobe Application Manager, Enterprise "
                        "Edition (AAMEE) to create an installation package "
                        "for this product.")
                    exit(-1)
                if not catinfo:
                    print >> sys.stderr, \
                        "Could not find a supported installer item in %s!" % \
                         item
                    exit(-1)
            
            elif item.endswith('.pkg') or item.endswith('.mpkg'):
                catinfo = munkicommon.getPackageMetaData(item)
                if not catinfo:
                    print >> sys.stderr, \
                        "%s doesn't appear to be a valid installer item!" % \
                         item
                    exit(-1)
                if os.path.isdir(item):
                    print >> sys.stderr, (
                        "WARNING: %s is a bundle-style package!\n"
                        "To use it with munki, you should encapsulate it "
                        "in a disk image.\n")  % item
                    # need to walk the dir and add it all up
                    for (path, unused_dirs, files) in os.walk(item):
                        for name in files:
                            filename = os.path.join(path, name)
                            # use os.lstat so we don't follow symlinks
                            itemsize += int(os.lstat(filename).st_size)
                    # convert to kbytes
                    itemsize = int(itemsize/1024)
           
            else:
                print >> sys.stderr, "%s is not an installer package!" % item
                exit(-1)
            
            if options.description:
                catinfo['description'] = options.description
            if options.displayname:
                catinfo['display_name'] = options.displayname

            catinfo['installer_item_size'] = int(itemsize/1024)
            catinfo['installer_item_hash'] = itemhash

            # try to generate the correct item location
            temppath = item
            location = ""
            while len(temppath) > 4:
                if temppath.endswith('/pkgs'):
                    location = item[len(temppath)+1:]
                    break
                else:
                    temppath = os.path.dirname(temppath)
                
            if not location:
                #just the filename
                location = os.path.split(item)[1]
            catinfo['installer_item_location'] = location
            
            # ADOBE STUFF - though maybe generalizable in the future?
            if options.uninstallerdmg:
                uninstallerpath = options.uninstallerdmg
                if os.path.exists(uninstallerpath):
                    # try to generate the correct item location
                    temppath = uninstallerpath
                    location = ""
                    while len(temppath) > 4:
                        if temppath.endswith('/pkgs'):
                            location = uninstallerpath[len(temppath)+1:]
                            break
                        else:
                            temppath = os.path.dirname(temppath)

                    if not location:
                        #just the filename
                        location = os.path.split(uninstallerpath)[1]
                    catinfo['uninstaller_item_location'] = location
                    itemsize = int(os.path.getsize(uninstallerpath))
                    itemhash = munkicommon.getsha256hash(uninstallerpath)
                    catinfo['uninstaller_item_size'] = int(itemsize/1024)
                    catinfo['uninstaller_item_hash'] = itemhash
                else:
                    print >> sys.stderr, "No uninstaller at %s" % \
                                        uninstallerpath
                    
            # some metainfo
            if options.catalog:
                catinfo['catalogs'] = options.catalog
            else:
                catinfo['catalogs'] = ['testing']
            if catinfo.get('receipts', None):
                catinfo['uninstallable'] = True
                catinfo['uninstall_method'] = "removepackages"
        
    minosversion = ""
    maxfileversion = "0.0.0.0.0"
    if catinfo:
        catinfo['autoremove'] = False
        if minosversion:
            catinfo['minimum_os_version'] = minosversion
        else:
            catinfo['minimum_os_version'] = "10.4.0"
        if not 'version' in catinfo:
            if maxfileversion != "0.0.0.0.0":
                catinfo['version'] = maxfileversion
            else:
                catinfo['version'] = "1.0.0.0.0 (Please edit me!)"
    
    if options.file:           
        for fitem in options.file:
            # no trailing slashes, please.
            fitem = fitem.rstrip('/')
            if fitem.startswith('/Library/Receipts'):
                # no receipts, please!
                print >> sys.stderr, \
                    "Item %s appears to be a receipt. Skipping." % fitem
                continue
            if os.path.exists(fitem):
                iteminfodict = getiteminfo(fitem)
                if 'minosversion' in iteminfodict:
                    thisminosversion = iteminfodict.pop('minosversion')
                    if not minosversion:
                        minosversion = thisminosversion
                    elif (munkicommon.MunkiLooseVersion(thisminosversion) < 
                          munkicommon.MunkiLooseVersion(minosversion)):
                        minosversion = thisminosversion
                if 'CFBundleShortVersionString' in iteminfodict:
                    thisitemversion = \
                        iteminfodict['CFBundleShortVersionString']
                    if (munkicommon.MunkiLooseVersion(thisitemversion) >
                        munkicommon.MunkiLooseVersion(maxfileversion)):
                        maxfileversion = thisitemversion
                installs.append(iteminfodict)
            else:
                print >> sys.stderr, (
                    "Item %s doesn't exist. Skipping." % fitem)

    if installs:
        catinfo['installs'] = installs
        
    if options.postinstall_script:
        scriptstring = readfile(options.postinstall_script)
        if scriptstring:
            catinfo['postinstall_script'] = scriptstring
    if options.preinstall_script:
        scriptstring = readfile(options.preinstall_script)
        if scriptstring:
            catinfo['preinstall_script'] = scriptstring
    if options.postuninstall_script:
        scriptstring = readfile(options.postuninstall_script)
        if scriptstring:
            catinfo['postuninstall_script'] = scriptstring
    if options.preuninstall_script:
        scriptstring = readfile(options.preuninstall_script)
        if scriptstring:
            catinfo['preuninstall_script'] = scriptstring
    if options.uninstall_script:
        scriptstring = readfile(options.uninstall_script)
        if scriptstring:
            catinfo['uninstall_script'] = scriptstring
            catinfo['uninstall_method'] = 'uninstall_script'

    # and now, what we've all been waiting for...
    print FoundationPlist.writePlistToString(catinfo)


if __name__ == '__main__':
    main()

